iT邦幫忙

2024 iThome 鐵人賽

DAY 6
0
自我挑戰組

30 天 vueuse 原始碼閱讀與實作系列 第 6

[Day 6] useThrottleFn - unit test & setTimeout 傳入超大負數導致的 bug

  • 分享至 

  • xImage
  •  

今天主要會講 useThrottleFn 的 unit test,另外還有一個關於 setTimeout 的 bug 也會一起解決~

setTimeout - 傳入超大負數導致 setTimeout 不會執行

在 trailing 為 true、leading 為 false 會出現這個情境

const throttledFn = useThrottleFn(updateValue, 3000, true, false)

第一次觸發時,setTimeout 設定的 timeout 值是 ms - Date.now() - 0,會是一個很大的負數。
我自己是先簡單理解成,這個數字超出了 32 位整數能表示的範圍,所以發生整數溢出,溢出後的結果可能是一個非常大的正數,導致 Timer 實際上被設置為在超級久的時間,以 MDN 的說法

當使用大於 2,147,483,647 毫秒(約 24.8 天)的延遲時,這會導致整數溢位。

MDN 連結:https://developer.mozilla.org/en-US/docs/Web/API/setTimeout#maximum_delay_value
要深入研究的話,可以參考這篇:https://www.andrewdoss.dev/writing/timeouts/

知道以上造成問題的原因後,可以在 timeout 計算出來為負數的時候,就設定為 0。

timer = setTimeout(() => {
  lastExec = Date.now()
  resolve(invoke())
  clear()
}, Math.max(0, ms - duration))

這個改動可以參考 vueuse GitHub 上的 PR:https://github.com/vueuse/vueuse/pull/2620
上面有提供案例可以試試~

unit test

先做一個 promiseTimeout function,主要是用來在測試中模擬延遲。

// src/utils/shared.js
export function promiseTimeout(ms, throwOnTimeout = false, reason = 'Timeout') {
  return new Promise((resolve, reject) => {
    if (throwOnTimeout)
      setTimeout(() => reject(reason), ms)
    else
      setTimeout(resolve, ms)
  })
}

測試預設情境

// src/compositions/useThrottleFn.test.js
it('should work', async () => {
    const callback = vi.fn()
    const ms = 20
    const run = useThrottleFn(callback, ms)
    run()
    run()
    // 連續 run 兩次後,只會觸發一次(預設 trailing 是 false)
    expect(callback).toHaveBeenCalledTimes(1)
    await promiseTimeout(ms + 10)
    run()
    // 等待一個超過 ms 的時間再次執行,所以會成功觸發
    expect(callback).toHaveBeenCalledTimes(2)
})

測試 trailing

這段測試可以搭配 Day3 trailing 的段落會比較清楚。

// src/compositions/useThrottleFn.test.js
it('should work with trailing', async () => {
  const callback = vi.fn()
  const ms = 20
  const run = useThrottle(callback, ms, true)
  run()
  run()
  expect(callback).toHaveBeenCalledTimes(1)
  await promiseTimeout(ms + 10)
  expect(callback).toHaveBeenCalledTimes(2)
})

大致上跟上面那個案例差不多,差別在等待 ms + 10 毫秒後,因為 trailing 的關係,不需要再
執行 run,callback 就會在時間到時被呼叫第二次 。

測試 leading 為 false

這段測試可以搭配 Day3 leading 的段落會比較清楚。

// src/compositions/useThrottleFn.test.js
it('should work with leading', async () => {
  const callback = vi.fn()
  const ms = 20
  const run = useThrottle(callback, ms, false, false)
  run() // 因為 leading 為 false,這次不執行
  run()
  expect(callback).toHaveBeenCalledTimes(1)
  await promiseTimeout(ms + 10)
  run() // 因為 leading 為 false,這次不執行
  run()
  run() // 因為 trailing 為 false,這次不執行
  expect(callback).toHaveBeenCalledTimes(2)
  await promiseTimeout(ms + 20)
  run() // 因為 leading 為 false,這次不執行
  expect(callback).toHaveBeenCalledTimes(2)
})

測試 createFilterWrapper + throttle

這部分的測試在 vueuse source code 中是被歸類在 filters 的測試,所以我在專案中另開一支 filter.test.js 來實作這部分的測試。另外有一點要注意的是 throttleFilter 中的 trailing, leading 預設都是 true,這個跟 useThrottleFn 中 trailing 預設是 false 不太一樣,這部分要先改過來測試才會通過。

// src/utils/filter.js
export function throttleFilter(ms, trailing = true, leading = true, rejectOnCancel = false) { // ...略 }

// src/compositions/useThrottleFn.js
export function useThrottleFn(fn, ms, trailing = false, leading = true, rejectOnCancel = false) {
  return createFilterWrapper(
    throttleFilter(ms, trailing, leading, rejectOnCancel),
    fn,
  )
}

有兩個相關案例

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createFilterWrapper, throttleFilter } from '@/utils/filter'

describe('filters', () => {
  beforeEach(() => {
    vi.useFakeTimers()
  })

  it('should throttle', () => {
    const throttledFilterSpy = vi.fn()
    const filter = createFilterWrapper(throttleFilter(1000), throttledFilterSpy)
    setTimeout(filter, 500) // 會觸發
    setTimeout(filter, 500)
    setTimeout(filter, 500)
    setTimeout(filter, 500) // 會觸發(trailing)

    vi.runAllTimers()

    expect(throttledFilterSpy).toHaveBeenCalledTimes(2)
  })

  it('should throttle evenly', () => {
    const debouncedFilterSpy = vi.fn()

    const filter = createFilterWrapper(throttleFilter(1000), debouncedFilterSpy)

    setTimeout(() => filter(1), 500)
    setTimeout(() => filter(2), 1000)
    setTimeout(() => filter(3), 2000)

    vi.runAllTimers()

    expect(debouncedFilterSpy).toHaveBeenCalledTimes(3)
    expect(debouncedFilterSpy).toHaveBeenCalledWith(1)
    expect(debouncedFilterSpy).toHaveBeenCalledWith(2)
    expect(debouncedFilterSpy).toHaveBeenCalledWith(3)
  })
})

第二個 should throttle evenly 的案例,因為卡了一下,所以寫出來做個紀錄。

0ms --- 開始
500ms --- 呼叫 filter(1) ,馬上執行 debouncedFilterSpy(1)
1000ms --- 呼叫 filter(2),因為距離上次執行還不到 1000ms,要在 1500ms 的時候才會執行
1500ms --- 執行 debouncedFilterSpy(2)
2000ms --- 執行 filter(3),因為距離上次執行還不到 1000ms,要在 2500ms 的時候才會執行
2500ms --- 執行 debouncedFilterSpy(3)

呼應到 should throttle evenly 這個案例名稱,在第一次執性 debouncedFilterSpy(1) 後,每隔 1000ms 會執行下一個,順序也正確。

GitHub:https://github.com/RhinoLee/30days_vue/pull/8/files


因為最近工作上有開始寫一些 unit test,看到原始碼這樣測 throttle 覺得很有趣,也學到了 vitest 中
useFakeTimers, runAllTimers 的用法,useThrottleFn 的 source code 也就到這邊告一段落~

明天開始會講跟畫面比較有關的 API - useParallax,轉換一下心情 XD


上一篇
[Day 5] useThrottleFn - Promise & rejectOnCancel
下一篇
[Day 7] useParallax - 序章
系列文
30 天 vueuse 原始碼閱讀與實作30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言